Skip to main content

Adding an App to Services User or Staff

This is a step-by-step guide to deploying a new web app on services-user (10.0.0.18) or services-staff (10.0.0.19). Two existing apps make good models:

  • leigh-dom is a public app with no secrets.
  • mao-teacher-portal is a public app that needs secrets.

If you're not an admin, steps 2 to 5 go in a pull request, and an admin runs step 6. See Making Changes.

1. Decide where it goes​

  • services-user for apps used by the public. services-staff for internal tools, and for back-end services that other apps call.
  • Public: the app gets its own hostname, an nginx vhost and a DNS record.
  • Internal-only: no hostname. Other services call it directly at 10.0.0.19:<port>, as the API gateway does with bunamo and gramsrv.

2. Add a compose file​

Create ansible/hetzner-main/services-user-apps/<app>/docker-compose.yml (or services-staff-apps/<app>/). The directory name is the app's name everywhere else: in the playbook list, in /opt/apps/<app> on the VM, and in the Vaultwarden note name.

services:
<app>:
image: 10.0.0.12:5000/<app>:main
container_name: <app>
ports:
- "<host-port>:<container-port>"
environment:
- SOME_SETTING=value
# env_file: .env # only if the app has secrets (step 4)
volumes:
- data:/data # only if the app stores data
restart: always

volumes:
data:

Pick a host port that isn't used on that VM. There's no central list, so check the services-user or services-staff app table and the other compose files.

Use a named volume for data the app creates itself. If the app needs existing data (files copied from somewhere else), copy it onto the VM by hand, into /opt/apps/<app>/, before the first start. Ansible doesn't do this.

3. Add it to the playbook​

Add the directory name to the app list:

  • user_apps in ansible/hetzner-main/setup-services-user.yml, or
  • staff_apps in ansible/hetzner-main/setup-services-staff.yml.

The apps tag deploys every app in that list.

4. Add secrets (only if needed)​

For API keys, database passwords, SMTP settings, or anything else that mustn't be in git:

  1. In Vaultwarden, create a Secure Note named exactly <app> .env: the directory name, a space, then .env. Put it in your project's collection. The note's content is the whole .env file.
  2. Add the app to user_apps_with_env (or staff_apps_with_env) in the same playbook.
  3. Add env_file: .env to the service in the compose file.

On every deploy, the playbook fetches the note and writes it to /opt/apps/<app>/.env (owner root:deploy, mode 0640). If the note is missing or empty, or the person deploying isn't in its collection, the deploy stops.

Values that aren't secret and are needed at build time (for example Next.js NEXT_PUBLIC_* variables) belong in the image build in CI, not in .env.

5. Public apps: add a vhost and a DNS record​

Create networking/nginx-config/<app>.abair.ie:

server {
listen 80;
listen [::]:80;
server_name <app>.abair.ie;
return 301 https://$server_name$request_uri;
}

server {
listen 443 ssl;
listen [::]:443 ssl;
server_name <app>.abair.ie;

ssl_certificate /etc/letsencrypt/live/abair.ie/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/abair.ie/privkey.pem;

location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_pass http://10.0.0.18:<host-port>; # 10.0.0.19 for services-staff
}
}

The wildcard certificate already covers <app>.abair.ie, so there's no certificate step. Add settings as the app needs them:

  • client_max_body_size for uploads;
  • WebSocket upgrade headers for live updates;
  • proxy_http_version 1.1 and proxy_set_header Connection "" for server-sent events.

leighdom.abair.ie and streaming-asr.abair.ie are good examples.

Add the DNS record​

In the Cloudflare dashboard, add a proxied record for <app>.abair.ie pointing at the Proxmox host's public IP, 136.243.40.234. This step isn't automated: the site won't be reachable until the record exists.

Internal-only apps​

Skip the vhost and DNS. If the API gateway calls the app, add its URL to ansible/hetzner-main/vars/api-gateway.yml and pass it into the gateway's .env in setup-api-gateway.yml. services-staff already allows all traffic from the gateway (10.0.0.17). For any other caller, add a UFW rule on the receiving VM.

6. Deploy​

eval "$(make unlock-bw)"
make deploy-services-user-apps # or deploy-services-staff-apps
make update-webserver # only if you added a vhost

The deploy target copies every app's directory, writes the .env files, and runs docker compose pull and docker compose up -d for each app. It doesn't touch the rest of the VM.

Then check that the app is running:

ssh services-user-vm "docker ps --filter name=<app>"
curl -I https://<app>.abair.ie

Consider adding an Uptime Kuma monitor for it (see Monitoring).

7. Set up CI/CD in the app's own repo​

The app's code lives in its own repository, not in infrastructure-automation. The usual pipeline runs on a self-hosted runner on docker-build, which is already logged in to the registry:

  1. Build and push the image:
    docker build -t 10.0.0.12:5000/<app>:main .
    docker push 10.0.0.12:5000/<app>:main
  2. Deploy with plain SSH. No SSH action or server credentials are needed in CI:
    ssh deploy@10.0.0.18 "cd /opt/apps/<app> && docker compose pull && docker compose up -d"

The deploy user on each VM trusts the runners' SSH keys. On services-user it only trusts the GitHub runner; on services-staff it trusts both the GitHub and Gitea runners. See Docker Build → Deploy keys.

Changes to the compose file in infrastructure-automation (a new environment variable, a different image tag) still need a make deploy-services-*-apps run. The app's CI only pulls a new image; it doesn't copy the compose file.